Conflicts
In the “Toggle” exercise from the previous lesson, we saw how delegating all props can lead to some issues if there are conflicts.
Let's look at a minimal example:
function Checkbox({ label, ...delegated}) { const id = React.useId();
return ( <> <label htmlFor={id}> {label} </label> <input id={id} type="checkbox" {...delegated} /> </> );}
This Checkbox
component applies two hardcoded attributes to the <input>
: type
and id
.
Now, suppose the consumer of this component uses it like this:
<Checkbox label="Do you agree to the terms?" type="button" onClick={handleAgreeToTerms}/>
The type
and onClick
props aren't specified in the Checkbox
component, and so they're collected into the delegated
object, and pasted onto the <input>
:
// Here's the React element that will be created:<input id={id} type="checkbox" type="button" onClick={handleAgreeToTerms}/>
We've specified two different values for type
, and when there are conflicts like this, later values overwrite earlier ones. And so, this input will be a button instead of a checkbox.
Essentially, the consumer has “hacked” our Checkbox component to not render a checkbox!
Let's rewrite our Checkbox
component to spread the provided props first:
function Checkbox({ label, ...delegated}) { const id = React.useId();
return ( <> <label htmlFor={id}> {label} </label> <input {...delegated} id={id} type="checkbox" /> </> );}
With this change, the same <Checkbox>
element produces a different result:
<input // Delegated props: type="button" onClick={handleAgreeToTerms} // Built-in attributes: id={id} type="checkbox"/>
// After removing the duplicate `type`, we're left with:<input onClick={handleAgreeToTerms} id={id} type="checkbox"/>
Because we've flipped the order, the user-supplied type="button"
will now be overwritten by the built-in type="checkbox"
.
A powerful tool in API design
When we produce React components, we get to decide how much power we want to give consumers. We can choose which properties they're allowed to overwrite, and which ones are mandatory / locked in.
In the example above, I feel pretty strongly that a Checkbox
component should always render an <input type="checkbox">
, and so I don't want to let consumers overwrite the type
attribute.
But this won't always be the case! Sometimes, I do want to let users overwrite the built-in attributes.
For example, suppose I have a component that generates an SVG icon:
function ArrowIcon({ size, ...delegated }) { return ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={size} height={size} > <path d="M 20 0 L 24 12 L 0 12 L 24 12 L 20 24" stroke="black" strokeLinecap="round" {...delegated} /> </svg> );}
By default, this component will render a black arrow with rounded lines, but I can supply my own overrides:
<ArrowIcon stroke="red" strokeLinecap="square" />
There's no right/wrong answer when it comes to where the {...delegated}
should go. Rather, it's a choice we can use as a tool, to decide how much power/flexibility I want to grant to the developers consuming this component.
Manually managing conflicts
Sometimes, delegated props is too blunt of a tool, and we need to do some manual work to resolve the conflict.
For example, when it comes to CSS classes, we often want to apply both the user-supplied class as well as the built-in one.
In the Toggle exercise, we manually merged the two classes together so that we were applying the toggle
class (which provided all of the standard toggle styling) as well as the green-toggle
class (a user-specified class with an override for the toggle's color).
I've built a lot of components that follow this exact template. Here's a minimum viable example, with all the other stuff stripped away:
function Template({ className = '' }) { const appliedClass = `built-in-class ${className}`;
return ( <div className={appliedClass} /> );}
In a sense, we've actually seen an example of this pattern already, when we talked about the Rules of Hooks:
function TextInput({ id, label, type }) { let generatedId = React.useId(); let appliedId = id || generatedId;
return ( <div className="text-input"> <label htmlFor={appliedId}> {label} </label> <input id={appliedId} type={type} /> </div> );}
If the user supplies an id
prop, it will be used for the input's id
, and the label's htmlFor
. If they don't, we'll use the generated value we get from the React.useId
hook.
We could rely on rest/spread to apply the correct id
on the <input>
, but we also need to set the exact same value on the <label>
, via the htmlFor
attribute. As a result, we need to manage this conflict manually.
Here's one more example, where we can supply custom inline styles to a component that already has some:
function ExampleComponent({ // User-specified styles. // Defaults to an empty object so that we always receive an // object, never “undefined”: style = {}, children, ...delegated}) { const builtInStyle = { padding: 16, background: 'red', };
return ( <div {...delegated} style={{ // Merge both sets of styles, prioritizing the // built-in styles: ...style, ...builtInStyle, }} > {children} </div> );}
To review, we have several options when it comes to conflicting attributes.
- If we want to allow the consumer to overwrite a particular hardcoded attribute, we can place the
{...delegated}
syntax afterwards. - If we want to prioritize the hardcoded attribute, however, the
{...delegated}
syntax should come first. - If we want to merge both values, we'll need to manage it ourselves, without using
{...delegated}
.
All 3 of these options are valid in different situations. It all comes down to how much control we want to grant the consumer.